Steam Controller: Phase 3a — IMU quaternion to visualizer + Test Report multi-interface fix#11
Merged
Conversation
Adds quaternion-orientation plumbing for the Steam Controller and the
supporting infrastructure (Test Report multi-interface fan-out, runtime
axis-transform switcher). Pitch + roll track physical motion correctly
in the on-screen model when using the field-tested 'yzp-xy-alt'
transform. Yaw signal is captured but ~10× weaker than pitch/roll
(typical for IMUs without a magnetometer), and visually not convincing
— left as a known limitation for future iteration.
Driver (packages/core/src/drivers/steam-controller-driver.js):
- Parses 4× int16 LE at bytes 31-38 as quaternion components, returns
parsed.orientation = {x, y, z, w}. The first component (bytes 31-32)
is actually the high half of the device's uint32 timestamp on this
firmware — SteamlessController's docs claim the quaternion lives at
data[31-38] but variance analysis of a real Test Report capture
shows bytes 29-32 are a ~1MHz uint32 timestamp. Three.js's normalize
flattens the timestamp byte's effect enough that pitch + roll
visualize correctly. Better encoding TBD — possibly Euler angles in
radians, didn't lead to working motion when tried.
- emitsRawGyro = false (already) flag tells app.js to skip the
rate-based calibration UX since the driver emits orientation, not
rates.
App (apps/overlay/src/js/app.js):
- Quaternion fast-path: when parsed.orientation is present, writes
directly into gyroFusion.orientation (bypasses SensorFusion). All
downstream consumers (overlay body rotation, gimbal widget, R/P/Y
readout, gyro HUD) pick it up unchanged.
- Reference-quaternion capture: first valid parsed.orientation
becomes the rest reference; every subsequent frame is emitted as
delta = current * ref⁻¹. L3+R3 (existing calibrate combo)
recaptures the reference. Hooks into existing showCalibHint UX so
user sees feedback when they recenter.
- Runtime axis-transform switcher: window.setSteamQuatTransform(mode)
cycles between 10+ candidate IMU-body→visualizer-world basis
transforms. Default 'yzp-xy-alt' = (z, x, y), found via empirical
iteration. Other transforms in the registry for future tuning.
Test Report wizard (apps/overlay/src/js/test-report.js):
- Multi-interface input fan-out: previously the wizard listened on
one Puck HID handle (whichever the picker returned) — that's the
wrong interface for the Puck, so every step recorded 0 reports.
Now mirrors the gyro pipeline's fan-out: enumerates approved
siblings with same vid:pid, opens + attaches the recorder to each.
Single-interface pads (DualSense filtered by usagePage) are no-op.
UI:
- Axis readout (P/R/Y degrees) moved from top-right to bottom-center.
Was overlapping the Puck status banner.
- HUD Position setting renamed → "Roll HUD Position" since it only
controls the gyro/roll arc widget, not the axis readout.
Verified via Test Report variance analysis on real hardware:
- Pitch movement primarily varies bytes 35-36 (353× baseline stddev)
- Roll movement primarily varies bytes 33-34 (467× baseline)
- Yaw movement varies all components weakly (max 59× baseline, but
absolute stddev 10× smaller than pitch/roll)
Phase 3a is sized as "best-with-current-understanding" — the right
IMU encoding remains unclear and the test infrastructure to iterate
further is in place for a future contributor.
This was referenced May 24, 2026
petegordon
added a commit
that referenced
this pull request
May 24, 2026
Replaces the quaternion-output path landed in #11 with the standard rate-based encoding used by DualSense. Per-axis Test Report variance analysis revealed the actual 2026 firmware layout differs from what SteamlessController documents: bytes 29-32 = uint32 LE timestamp (~1 MHz clock) bytes 33-38 = 3-axis accelerometer (int16 LE, ±2g full scale) bytes 39-44 = 3-axis gyroscope (int16 LE, ±2000 dps) bytes 45+ = always-zero padding SteamlessController claimed a 4-int16 quaternion at data[31-38] but that overlaps the timestamp on this firmware. With the corrected layout, both sensors look exactly like a DualSense: - gyro: 0 ± 0.2 dps across all axes at rest (perfect zero-bias) - accel: gravity ≈ +1g on body-Z when flat face-up So we flow them straight into the existing SensorFusion pipeline — calibration, drift correction, orientation integration, the L3+R3 recalibrate combo all reuse the proven DualSense code path. No quaternion fast-path, no setSteamQuatTransform DevTools switcher, no reference-quaternion capture. Body-to-visualizer frame remap is in the driver: - Swap Y ↔ Z on both gyro and accel: moves gravity from body-Z to body-Y to match Three.js's "Y up" convention used by DualSense. - Negate Z on both: flips roll direction to match visualizer roll. Field-verified on a 2026 Steam Controller — pitch, roll, AND yaw all track physical motion correctly. The IMU yaw signal is fully usable once read from the right bytes (~34 dps stddev during yaw step, same magnitude as pitch/roll). Known minor issue: ~6-7° residual pitch drift settling on initial calibration. Acceptable for now; can be tuned later via the SensorFusion calibration thresholds. App.js cleanup removes the quaternion-direct scaffolding from #11: - _steamRawQuat / _steamDelta / _steamRefQuat / recalibrateSteamReference - STEAM_QUAT_TRANSFORMS registry + window.setSteamQuatTransform - parsed.orientation fast-path - calibration-skip guard (we DO calibrate now, same as DualSense) Profile change: gyroTransform stays identity (driver does the remap internally — same pattern as Switch Pro). Comment clarifies the field is informational on this profile. Closes the IMU-encoding ambiguity left open in #11.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wires the Steam Controller's IMU into the visualizer's body rotation. Pitch + roll track physical motion correctly; yaw is captured but ~10× weaker (typical for IMUs without a magnetometer) and not visually compelling — left as a known limitation.
Also fixes the Test Report wizard's multi-interface listener gap that was causing zero-report captures on the Puck.
Summary
parsed.orientation. The first component overlaps the firmware's uint32 timestamp at bytes 29-32, but Three.js's normalize smooths it enough that pitch + roll visualize correctly with the right axis transform.parsed.orientation, writes it directly intogyroFusion.orientation, bypassing the rate-based SensorFusion pipeline. All existing consumers (overlay body rotation, gimbal widget, R/P/Y readout, gyro HUD) pick it up unchanged.window.setSteamQuatTransform(mode)cycles between 10+ candidate basis transforms. Default'yzp-xy-alt'= (z, x, y), field-tested as best of options.Verified via Test Report variance analysis on real hardware
What's NOT done / known issues
Commits
8a60653Phase 3a IMU quaternion to visualizer + UI fixesTest plan
Refs #8.